Have you ever wondered if it was possible to consistently profit from sports betting? For two months from June to August, I put my own money on the line ($200 to be exact) using simple, easy-to-follow betting methods to create this comprehensive dataset to analyze. To clarify, this method uses third-party projections, and this project is not about building our own projections. This projects seeks to find out if a bettor can use third-party projections to identify gaps between the true odds of an event and the posted odds in a sportsbook to profit using a practice called positive EV betting.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import plotly.express as px
import plotly
import seaborn as sns
import scipy.stats
plotly.offline.init_notebook_mode()
sb_data = pd.read_csv("strikeout_betting.csv")
print(sb_data.columns)
sb_data.head(10)
Index(['Date', 'Team', 'Pitcher', 'SO_Projection', 'Model_Id', 'OU',
'Over_Odds', 'Scale_Over', 'Under_Odds', 'Scale_Over.1', 'BE_Over',
'BE_Under', 'Poisson_Over', 'Poison_Under', 'Edge_Over', 'Edge_Under',
'OverUnder_Id', 'Platform', 'Bet_Amt', 'Win_Loss', 'Net', 'Net_Auto',
'Secondary'],
dtype='object')
| Date | Team | Pitcher | SO_Projection | Model_Id | OU | Over_Odds | Scale_Over | Under_Odds | Scale_Over.1 | ... | Poison_Under | Edge_Over | Edge_Under | OverUnder_Id | Platform | Bet_Amt | Win_Loss | Net | Net_Auto | Secondary | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 6/14/2022 | ATL | Max Fried | 4.78 | 1 | 5.5 | 110.0 | 1.100000 | -145 | -0.689655 | ... | 0.654 | -13.069% | 6.266% | 1 | 1 | 7.0 | L | -7.00 | -7.00 | NaN |
| 1 | 6/14/2022 | PIT | JT Brubaker | 4.03 | 1 | 4.5 | 100.0 | 1.000000 | -130 | -0.769231 | ... | 0.623 | -12.298% | 5.776% | 1 | 1 | 5.0 | L | -5.00 | -5.00 | NaN |
| 2 | 6/14/2022 | STL | Matthew Liberatore | 4.98 | 1 | 3.5 | -140.0 | -0.714286 | 105 | 1.050000 | ... | 0.268 | 14.882% | -21.996% | 2 | 1 | 10.0 | W | 7.14 | 7.14 | NaN |
| 3 | 6/14/2022 | BOS | Nick Pivetta | 5.44 | 1 | 6.5 | -105.0 | -0.952381 | -125 | -0.800000 | ... | 0.695 | -20.763% | 13.988% | 1 | 1 | 25.0 | W | 20.00 | 20.00 | NaN |
| 4 | 6/14/2022 | CLE | Shane Bieber | 5.29 | 1 | 5.5 | -150.0 | -0.666667 | 115 | 1.150000 | ... | 0.565 | -16.521% | 10.010% | 1 | 1 | 25.0 | L | -25.00 | -25.00 | NaN |
| 5 | 6/14/2022 | NYY | Gerrit Cole | 6.72 | 1 | 8.5 | 125.0 | 1.250000 | -165 | -0.606061 | ... | 0.765 | -20.924% | 14.216% | 1 | 1 | 25.0 | W | 15.15 | 15.15 | NaN |
| 6 | 6/14/2022 | MIN | Joe Ryan | 4.32 | 1 | 4.5 | 100.0 | 1.000000 | -135 | -0.740741 | ... | 0.567 | -6.658% | -0.789% | 0 | 1 | 0.0 | NaN | NaN | NaN | NaN |
| 7 | 6/14/2022 | KC | Kris Bubic | 4.24 | 1 | 3.5 | -125.0 | -0.800000 | -105 | -0.952381 | ... | 0.388 | 5.641% | -12.416% | 2 | 1 | 6.0 | W | 4.80 | 4.80 | NaN |
| 8 | 6/14/2022 | CWS | Dylan Cease | 7.22 | 1 | 7.5 | -115.0 | -0.869565 | -115 | -0.869565 | ... | 0.566 | -10.085% | 3.109% | 0 | 1 | 0.0 | NaN | NaN | NaN | NaN |
| 9 | 6/14/2022 | SF | Logan Webb | 5.24 | 1 | 4.5 | -160.0 | -0.625000 | 120 | 1.200000 | ... | 0.399 | -1.482% | -5.511% | 0 | 1 | 0.0 | NaN | NaN | NaN | NaN |
10 rows × 23 columns
To begin, Let's look at some descriptive statistics to get a better sense of the data.
First, let's answer the big juicy question: how much money did this experiment make?
The first calculation is the percentage return from betting. (The percentage return of the total dollar amount wagered)
The second calculation is the return on initial investment, which was $200
print('Initial Investment: $200 \nTotal Amount Wagered: $8618 \nReturns: $79.99\n')
print('Betting Returns: ' + str((sb_data['Net'].sum()/sb_data['Bet_Amt'].sum()*100).round(3)) + '%')
print('ROI: ' + str((sb_data['Net'].sum()/200*100).round(3)) + '%')
print(sb_data['Net'].sum())
Initial Investment: $200 Total Amount Wagered: $8618 Returns: $79.99 Betting Returns: 0.928% ROI: 39.995% 79.99000000000001
wins = sb_data['Win_Loss'].value_counts()['W']
losses = sb_data['Win_Loss'].value_counts()['L']
win_rate = wins/(wins+losses)
print('Total Number of Bets: ' + str(wins+losses))
print('Win Rate: ' + str((win_rate*100).round(2)) + '%')
Total Number of Bets: 598 Win Rate: 54.68%
What most people might be interested in is the returns from initial investment, and as you can see, it is quite high for a two-month experiment. However, please do not quit your job just yet and throw your life savings into sports betting using this method. THIS EXPERIMENT IS NOT INVESTMENT ADVICE. The up-and-down swings of cumulative returns from day-to-day were still quite wild, suggesting that two months was not nearly enough time to conclude the profitability of this method.
After seeing the money-making potential, I bet you're willing to sit through the explanation now. Online sportsbooks allow you to bet on a large varieties of events, from wins, scoring, individual player performance, and other sport specific events. For baseball, one such available event is the number of strikeouts that a pitcher gets in a game. A quick refresher: it's three strikes and you're out! Using the projection tool SaberSim, I retrieved the daily strikeout projections for each pitcher that day and compared it to their over-under line for strikeouts. If the over-under line, or O/U, is 4.5 for example, you can either bet the pitcher throws at least 5 strikeouts or at most 3 strikeouts. Using the projection, I calculated the probability of either outcome happening by assuming a poisson distribution and then calculated the betting edge based on the posted odds on the sportsbook. Finding the "edge" means to identify bets that have a higher probability of happening than what the odds suggest. Over time, this gives you a positive expected value in returns, assuming the betting edge is accurate. SaberSim has its own complex model that takes in play-by-play data from the past 10 years then simulates games play-by-play to get projections. More on how SaberSim works for baseball can be found here
Here is the more of the math behind this experiment, if you're interested. In order to calculate the probability of the pitcher throwing either over or under the given betting line, we assume it approximates a poisson distribution. I have not yet rigorously tested the validity of the assumption (I will soon), but so far, it has worked fairly well. The poisson distribution is a discrete distribution that measures the probability of a given number of events happening in a specified period of time if these events occur with a known constant mean rate and independently of the time since the last event. The first part is satisfied pretty easily: a given number of events (strikeouts) happening in a specified period (a game). It is the second part where we have to potentially shoehorn in further assumptions. We have to assume that the projection applies as the mean rate for a given pitcher on that given day (because the projections are updated for each of their appearances) and that each strikeout event is independent of one another (which you can argue is note entirely true due to psychological factors). At this point, there are two major unknowns: how well pitcher strikeouts are approximated by a poisson distribution and how well the projections approximate the mean in a poisson distribution. These questions need further testing and will require data that is not present in the dataset here. My hope is that this analysis will also lead us to ask more insightful statistical questions further down the road.
model_results = sb_data.groupby(['Model_Id']).agg({'Net':['sum'],'Bet_Amt':['sum']})
model_results.columns = model_results.columns.map('_'.join)
model_results['profit'] = (model_results['Net_sum']/model_results['Bet_Amt_sum']).round(3)
model_results
| Net_sum | Bet_Amt_sum | profit | |
|---|---|---|---|
| Model_Id | |||
| 1 | 52.59 | 7436.9 | 0.007 |
| 2 | 27.40 | 1181.1 | 0.023 |
Let's first look at the distribution of all the strikeout projections.
print('Strikeout Projection Descriptive Statistics: \n' + str(sb_data['SO_Projection'].describe()))
fig = px.histogram(
sb_data,
x='SO_Projection',
nbins= 20,
title='Histogram of SO Projections',
width=800
)
fig.show()
Strikeout Projection Descriptive Statistics: count 1413.000000 mean 5.091295 std 1.074422 min 2.370000 25% 4.320000 50% 4.990000 75% 5.780000 max 8.700000 Name: SO_Projection, dtype: float64
It's slightly skewed to the right and with a mean of 5.09
The strikeout projections from SaberSim are essentially the averages of all their simulated games, so we can think of that as an average of a "sample" of simulated games. This distribution can then be thought of as the sampling distribution of pitcher strikeouts. However, the pitchers recorded in this dataset are starting pitchers, which means that they rotate on a schedule of around 5 days. This means that the data are not independent because the same pitchers show up several times and will have similar projections based on their previous performance. To get an independent sample of individual pitcher distributions, we can randomly pick one instance of a projection for each pitcher.
np.random.seed(70062) #iykyk
# Selecting a random projection from each pitcher
sb_data_cleaned = sb_data.drop(sb_data[sb_data.Secondary == 1].index) # remove rows where Secondary == 1
sampled_sb_data = sb_data[['Pitcher','SO_Projection']].groupby('Pitcher').agg(np.random.choice)
print('Sampled Strikeout Projection Descriptive Statistics: \n' + str(sampled_sb_data['SO_Projection'].describe()))
fig = px.histogram(
sampled_sb_data,
x='SO_Projection',
nbins= 20,
title='Histogram of SO Projections',
width=800
)
fig.show()
Sampled Strikeout Projection Descriptive Statistics: count 184.000000 mean 4.886413 std 1.079404 min 2.720000 25% 3.977500 50% 4.730000 75% 5.592500 max 8.700000 Name: SO_Projection, dtype: float64
The results here show a slightly lower mean at 4.89 and the majority of projections between 3 and 6 strikeouts. In terms of likelihood to win bets overall, it looks favorable to bet towards this range, especially when the betting line is below 3 or above 6.
To get a better idea of which O/U lines are more favorable to bet on, we can look at each of the O/U lines by winnings and win percentage. We will start with 'Model 1' which is where we only use the SaberSim strikeout projection to bet. There is another model,'Model 2', which uses another projection in addition to SaberSim.
Note: We will only be analyzing 'Model 1' because 'Model 2' was even more experimental and a bit unecessarily complicated for our purposes.
# Outcomes grouped by OU
model_1 = sb_data_cleaned[sb_data_cleaned['Model_Id']==1].reset_index() # dataframe with just model 1, secondary removed
ou_results = model_1.groupby(['OU']).agg({'Net':['sum'],'Bet_Amt':['sum']})
ou_results.columns = ou_results.columns.map('_'.join)
ou_results['Wins'] = sb_data.groupby('OU')['Win_Loss'].apply(lambda x: (x=='W').sum())
ou_results['Losses'] = sb_data.groupby('OU')['Win_Loss'].apply(lambda x: (x=='L').sum())
ou_results['Unders'] = sb_data.groupby('OU')['OverUnder_Id'].apply(lambda x: (x==1).sum())
ou_results['Overs'] = sb_data.groupby('OU')['OverUnder_Id'].apply(lambda x: (x==2).sum())
ou_results['Win_Pct'] = (ou_results['Wins']/(ou_results['Wins'] + ou_results['Losses'])).round(3)
ou_results
| Net_sum | Bet_Amt_sum | Wins | Losses | Unders | Overs | Win_Pct | |
|---|---|---|---|---|---|---|---|
| OU | |||||||
| 2.5 | 56.25 | 280.50 | 17 | 6 | 0 | 23 | 0.739 |
| 3.5 | 64.24 | 1781.55 | 77 | 53 | 19 | 113 | 0.592 |
| 4.5 | -81.26 | 1979.70 | 91 | 89 | 53 | 127 | 0.506 |
| 5.5 | 168.52 | 1683.45 | 82 | 57 | 92 | 49 | 0.590 |
| 6.5 | -7.01 | 1082.70 | 39 | 41 | 67 | 13 | 0.488 |
| 7.5 | -214.10 | 441.80 | 13 | 19 | 31 | 1 | 0.406 |
| 8.5 | 30.95 | 151.60 | 7 | 6 | 13 | 0 | 0.538 |
| 9.5 | 15.00 | 15.60 | 1 | 0 | 1 | 0 | 1.000 |
This is a lot to take in, but let's break it down. The first column 'Net_sum' essentially shows the 'profit' made from each OU line. 'Bet_Amt_sum is the total amount wagered for the corresponding OU line. As you can see, the vast majority of bets were made on the '3.5','4.5', and '5.5' lines, which is what we expect based on the distribution of strikeout projections. Most projections fall between 3 and 6. The lines posted on the sportsbook are also a combination of the sportsbook's own projections and how people are betting. It's in the sportsbook's best interest to split the bettors as close to 50/50 as possible, so they like to make accurate projections too. The 'Wins' and 'Losses' columns should be self-explanatory. 'Unders' and 'Overs' means the number of bets that were either made under or over the line. 'Win_Pct' Should also be pretty self-explanatory.
Looking at the number of 'Unders' and 'Overs', we can see that the bets tended to be made towards the mean of 4.89 ('3.5' had more 'Over' bets and '5.5' had more 'Under' bets). The bets were only made if the model calculated a betting edge of over 5% (meaning you win the bet +5% more often than breaking even, according to the model), so the model also tended to suggest betting towards the mean.
The success of betting towards the mean can be reflected in the win percentages for '3.5', '4.5', '5.5', and even '2.5'. However, the same cannot be said for '6.5', '7.5', and '4.5' ('4.5' won barely more than 50% of the time which was not enough profit overall). This is likely due to the model breaking down at some point(s) on the distribution. Remember, we very roughly assumed that the strikeouts and strikeout projections fit a poisson distribution, but if the model breaks down at some point, we know that the poisson distribution doesn't fit completely. We could sit here an think about some intuitive reasons on why the poisson distribution doesn't fit for these OU lines (which I have thought about), but I would rather test this empirically later on. Nevertheless, the model in its current state seems to work pretty well for the OU lines '2.5', '3.5', '5.5', and possibly '8.5'. It could be a sound strategy to only bet on these lines using the current model. Another adjustment could be to increase the betting edge threshhold from 5% to 10% for example, so we only place more advantageous bets, potentially covering up for more of the error in the model. We can't increase it too much though, otherwise there won't be enough bets to place.
model_1.Date=pd.to_datetime(model_1.Date)
date_results = model_1.groupby(['Date']).agg({'Win_Loss':['count']}) # betting outcomes by date
date_results.columns = date_results.columns.map('_'.join)
date_results['Wins'] = model_1.groupby('Date')['Win_Loss'].apply(lambda x: (x=='W').sum())
date_results['Cuml_Wins'] = date_results['Wins'].cumsum()
date_results['Cuml_Total'] = date_results['Win_Loss_count'].cumsum()
date_results['Cuml_Win_Pct'] = date_results['Cuml_Wins']/date_results['Cuml_Total']
date_results.reset_index(inplace=True)
date_results
| Date | Win_Loss_count | Wins | Cuml_Wins | Cuml_Total | Cuml_Win_Pct | |
|---|---|---|---|---|---|---|
| 0 | 2022-06-14 | 11 | 6 | 6 | 11 | 0.545455 |
| 1 | 2022-06-15 | 8 | 3 | 9 | 19 | 0.473684 |
| 2 | 2022-06-16 | 8 | 4 | 13 | 27 | 0.481481 |
| 3 | 2022-06-17 | 8 | 5 | 18 | 35 | 0.514286 |
| 4 | 2022-06-18 | 9 | 5 | 23 | 44 | 0.522727 |
| 5 | 2022-06-19 | 13 | 8 | 31 | 57 | 0.543860 |
| 6 | 2022-06-20 | 4 | 0 | 31 | 61 | 0.508197 |
| 7 | 2022-06-21 | 7 | 7 | 38 | 68 | 0.558824 |
| 8 | 2022-06-22 | 15 | 10 | 48 | 83 | 0.578313 |
| 9 | 2022-06-23 | 9 | 5 | 53 | 92 | 0.576087 |
| 10 | 2022-06-24 | 7 | 3 | 56 | 99 | 0.565657 |
| 11 | 2022-06-25 | 9 | 6 | 62 | 108 | 0.574074 |
| 12 | 2022-06-27 | 7 | 2 | 64 | 115 | 0.556522 |
| 13 | 2022-06-28 | 8 | 5 | 69 | 123 | 0.560976 |
| 14 | 2022-06-29 | 12 | 5 | 74 | 135 | 0.548148 |
| 15 | 2022-06-30 | 5 | 3 | 77 | 140 | 0.550000 |
| 16 | 2022-07-01 | 6 | 4 | 81 | 146 | 0.554795 |
| 17 | 2022-07-02 | 3 | 1 | 82 | 149 | 0.550336 |
| 18 | 2022-07-03 | 4 | 0 | 82 | 153 | 0.535948 |
| 19 | 2022-07-04 | 9 | 6 | 88 | 162 | 0.543210 |
| 20 | 2022-07-05 | 6 | 0 | 88 | 168 | 0.523810 |
| 21 | 2022-07-06 | 9 | 6 | 94 | 177 | 0.531073 |
| 22 | 2022-07-07 | 3 | 2 | 96 | 180 | 0.533333 |
| 23 | 2022-07-08 | 13 | 8 | 104 | 193 | 0.538860 |
| 24 | 2022-07-09 | 15 | 12 | 116 | 208 | 0.557692 |
| 25 | 2022-07-10 | 9 | 8 | 124 | 217 | 0.571429 |
| 26 | 2022-07-11 | 3 | 2 | 126 | 220 | 0.572727 |
| 27 | 2022-07-12 | 11 | 6 | 132 | 231 | 0.571429 |
| 28 | 2022-07-13 | 11 | 6 | 138 | 242 | 0.570248 |
| 29 | 2022-07-14 | 7 | 3 | 141 | 249 | 0.566265 |
| 30 | 2022-07-15 | 10 | 6 | 147 | 259 | 0.567568 |
| 31 | 2022-07-23 | 1 | 0 | 147 | 260 | 0.565385 |
| 32 | 2022-07-25 | 1 | 0 | 147 | 261 | 0.563218 |
| 33 | 2022-07-30 | 4 | 2 | 149 | 265 | 0.562264 |
| 34 | 2022-07-31 | 1 | 1 | 150 | 266 | 0.563910 |
| 35 | 2022-08-01 | 1 | 1 | 151 | 267 | 0.565543 |
| 36 | 2022-08-02 | 2 | 2 | 153 | 269 | 0.568773 |
| 37 | 2022-08-03 | 1 | 0 | 153 | 270 | 0.566667 |
| 38 | 2022-08-04 | 1 | 0 | 153 | 271 | 0.564576 |
| 39 | 2022-08-06 | 1 | 0 | 153 | 272 | 0.562500 |
| 40 | 2022-08-10 | 10 | 8 | 161 | 282 | 0.570922 |
| 41 | 2022-08-11 | 5 | 4 | 165 | 287 | 0.574913 |
| 42 | 2022-08-12 | 8 | 5 | 170 | 295 | 0.576271 |
| 43 | 2022-08-20 | 9 | 4 | 174 | 304 | 0.572368 |
| 44 | 2022-08-21 | 14 | 8 | 182 | 318 | 0.572327 |
| 45 | 2022-08-22 | 6 | 4 | 186 | 324 | 0.574074 |
| 46 | 2022-08-23 | 8 | 5 | 191 | 332 | 0.575301 |
| 47 | 2022-08-24 | 8 | 3 | 194 | 340 | 0.570588 |
| 48 | 2022-08-25 | 11 | 7 | 201 | 351 | 0.572650 |
| 49 | 2022-08-26 | 9 | 4 | 205 | 360 | 0.569444 |
| 50 | 2022-08-27 | 13 | 9 | 214 | 373 | 0.573727 |
| 51 | 2022-08-28 | 6 | 2 | 216 | 379 | 0.569921 |
| 52 | 2022-08-29 | 4 | 1 | 217 | 383 | 0.566580 |
| 53 | 2022-08-30 | 11 | 5 | 222 | 394 | 0.563452 |
| 54 | 2022-08-31 | 11 | 4 | 226 | 405 | 0.558025 |
| 55 | 2022-09-04 | 13 | 6 | 232 | 418 | 0.555024 |
| 56 | 2022-09-05 | 7 | 1 | 233 | 425 | 0.548235 |
| 57 | 2022-09-06 | 7 | 3 | 236 | 432 | 0.546296 |
| 58 | 2022-09-08 | 4 | 1 | 237 | 436 | 0.543578 |
fig = px.line( # cumulative win pct over time
date_results,
x='Date',
y='Cuml_Win_Pct',
width=800
)
fig.show()
From the charts and visualizations above, we have learned quite a few things about the data that we have. We talked about how the model worked, saw how it behaved, and learned about what kind of bets were more favorable than others. Now we can ask the most important question of all, the most important to anyone with their hard-earned money on the line. Was it luck?
In order to answer this question, we can think of our data as the sample of a population. Then we can bootstrap it to calculate a confidence interval and do a hypothesis test with the null hypothesis that the sample sum is 0. Bootstrapping gives us more flexibility in testing because it doesn't make any assumptions about the distribution of our data.(Our sample size is probably big enough to assume the normality of the sampling distribution. Now let's bootstrap the results of Model 1 to get an idea of whether we got lucky or if the method is reliably profitable.
np.random.seed(70062)
bootstrap_sb_data = model_1.dropna(subset=['Net'])
print(len(bootstrap_sb_data))
print(bootstrap_sb_data['Net'].sum())
boot = 10000
bootstrapped_samples = []
for i in range(boot):
sample = bootstrap_sb_data['Net'].sample(frac=1, replace=True)
bootstrapped_samples.append(sample.sum())
bootstrapped_samples = pd.Series(bootstrapped_samples)
436 32.59000000000003
mean = np.mean(bootstrapped_samples)
fig = px.histogram(
x = bootstrapped_samples,
title='Bootstrap Model 1',
width=800
)
fig.update_layout(shapes=[
dict(
type='line',
yref='paper', y0 = 0, y1 = 1,
xref='x', x0 = mean, x1=mean,
line_color='red', line_width=1
)
])
fig.show()
print(bootstrapped_samples.describe())
count 10000.000000 mean 30.084521 std 350.487657 min -1465.060000 25% -202.955000 50% 28.935000 75% 265.117500 max 1286.130000 dtype: float64
So this is not looking good at first glance. I don't think we need to calculate a confidence interval to see that the model does not reliably make money (our 95% CI interval needs to exclude 0 for us to reject the null hypothesis). However, we have to remember that choosing what bets to make is a complex interaction between the SaberSim projection, the betting line, the betting odds, the betting edge calculated, and the fit of the poisson distribution. The projections, the lines, and the way people bet also change across a season. The "population" of outcomes using this model can look very different as the seaason progresses. It's possible that there are certain times in the season that are more profitable, where the model gives better advantages in betting. Therefore, we may not be able to generalize the results to all parts of a season. There are two separate one-month periods where I mainly used 'Model 1'. From 6/14 - 7/15 and 8/10 - 9/8. During the period in between, I experimented with another model.
We can try to bootstrap each period separately. I would expect the results to be quite different as there was a large drop-off in cumulative win percentage in the second period.
np.random.seed(70062)
bootstrap_sb_data_1 = bootstrap_sb_data[bootstrap_sb_data['Date'] <= '2022-07-15'] #filtering for 6/14 - 7/15
boot = 10000
print(bootstrap_sb_data_1['Net'].sum())
bootstrapped_samples = []
for i in range(boot):
sample = bootstrap_sb_data_1['Net'].sample(frac=1, replace=True)
bootstrapped_samples.append(sample.sum())
bootstrapped_samples = pd.Series(bootstrapped_samples)
363.74
mean = np.mean(bootstrapped_samples)
fig = px.histogram(
x = bootstrapped_samples,
title='Bootstrap Model 1 6/14-7/15',
width=800
)
fig.update_layout(shapes=[
dict(
type='line',
yref='paper', y0 = 0, y1 = 1,
xref='x', x0 = mean, x1=mean,
line_color='red', line_width=1
)
])
fig.show()
print(bootstrapped_samples.describe())
sorted_samples = bootstrapped_samples.sort_values()
print(sorted_samples.iloc[249],sorted_samples.iloc[9749]) # range of 95% CI
count 10000.000000 mean 361.436553 std 245.102471 min -595.470000 25% 196.645000 50% 361.645000 75% 524.800000 max 1300.270000 dtype: float64 -117.69 848.31
np.random.seed(70062)
bootstrap_sb_data_2 = bootstrap_sb_data[bootstrap_sb_data['Date'] >= '2022-08-10'] # filtering for 8/10 - 9/8
boot = 10000
bootstrapped_samples = []
for i in range(boot):
sample = bootstrap_sb_data_2['Net'].sample(frac=1, replace=True)
bootstrapped_samples.append(sample.sum())
bootstrapped_samples = pd.Series(bootstrapped_samples)
mean = np.mean(bootstrapped_samples)
fig = px.histogram(
x = bootstrapped_samples,
title='Bootstrap Model 1 8/10-9/8',
width=800
)
fig.update_layout(shapes=[
dict(
type='line',
yref='paper', y0 = 0, y1 = 1,
xref='x', x0 = mean, x1=mean,
line_color='red', line_width=1
)
])
fig.show()
print(bootstrapped_samples.describe())
sorted_samples = bootstrapped_samples.sort_values()
print(sorted_samples.iloc[249],sorted_samples.iloc[9749]) # range of 95% CI
count 10000.000000 mean -314.855165 std 244.693543 min -1263.000000 25% -480.812500 50% -315.575000 75% -148.250000 max 613.950000 dtype: float64 -793.5500000000001 165.85000000000002
Now there is a clear difference in the two time periods. The first period earns significantly more, and the second period seems to be losing very much. However, the CI interval of the first period still includes 0.
What if we pretended that, knowing what we know now, we went back in time and changed the model to only wager on the OU lines that we observed to be most profitable. Could that give us the best chance at guaranteeing ourselves a profit?
np.random.seed(70062)
bootstrap_sb_data_3 = bootstrap_sb_data[bootstrap_sb_data['OU'].isin([2.5,3.5,5.5])] #filter for rows with OU of 2.5, 3.5, 5.5
boot = 10000
bootstrapped_samples = []
for i in range(boot):
sample = bootstrap_sb_data_3['Net'].sample(frac=1, replace=True)
bootstrapped_samples.append(sample.sum())
bootstrapped_samples = pd.Series(bootstrapped_samples)
mean = np.mean(bootstrapped_samples)
fig = px.histogram(
x = bootstrapped_samples,
title='Bootstrap Model 1 OU of 2.5,3.5, and 5.5',
width=800
)
fig.update_layout(shapes=[
dict(
type='line',
yref='paper', y0 = 0, y1 = 1,
xref='x', x0 = mean, x1=mean,
line_color='red', line_width=1
)
])
fig.show()
print(bootstrapped_samples.describe())
sorted_samples = bootstrapped_samples.sort_values()
print(sorted_samples.iloc[249],sorted_samples.iloc[9749]) # range of 95% CI
count 10000.000000 mean 293.540224 std 243.007215 min -613.140000 25% 131.565000 50% 300.420000 75% 459.157500 max 1153.850000 dtype: float64 -194.19999999999996 760.95
The CI still includes 0, but this is also for both periods. Let's try filtering for the most profitable OU lines and the first period.
np.random.seed(70062)
bootstrap_sb_data_4 = bootstrap_sb_data_3[bootstrap_sb_data_3['Date'] <= '2022-07-15'] # filtering for OU 2.5, 3.5, 5.5 and before 7/15
print(bootstrap_sb_data_4['Net'].sum())
boot = 10000
bootstrapped_samples = []
for i in range(boot):
sample = bootstrap_sb_data_4['Net'].sample(frac=1, replace=True)
bootstrapped_samples.append(sample.sum())
bootstrapped_samples = pd.Series(bootstrapped_samples)
359.26
mean = np.mean(bootstrapped_samples)
fig = px.histogram(
x = bootstrapped_samples,
title='Bootstrap Model 1 OU of 2.5,3.5,5.5 and from 6/14-7/15',
width=800
)
fig.update_layout(shapes=[
dict(
type='line',
yref='paper', y0 = 0, y1 = 1,
xref='x', x0 = mean, x1=mean,
line_color='red', line_width=1
)
])
fig.show()
print(bootstrapped_samples.describe())
sorted_samples = bootstrapped_samples.sort_values()
print(sorted_samples.iloc[249],sorted_samples.iloc[9749]) # range of 95% CI
count 10000.00000 mean 361.30217 std 157.67690 min -267.99000 25% 256.98000 50% 362.87000 75% 466.82000 max 984.74000 dtype: float64 50.95000000000002 669.8199999999999
The 95% CI here is above 0. Now it seems we have a "method" for making a profit with statistical significance at the 95% confidence level.
The overall conclusion is that we do not have staistically significant evidence that this method is profitable over an entire baseball season. It is highly likely that most of our assumptions do not hold over the course of the season or for certain types of bets. However, there is a silver lining. It is possible that under very specific conditions that our assumptions hold up well in which this model could generate reliable profit: an interesting (and lucrative) question to investigate further.